#!/usr/bin/env node

import { readFileSync, writeFileSync, existsSync, globSync } from "node:fs";
import { dirname, join } from "node:path";

if (process.argv.length !== 3 || !/^\d+\.\d+\.\d+$/.test(process.argv[2])) {
    process.stderr.write(
        [
            `USAGE: ${process.argv[1]} <new-version>`,
            "",
            "Walks through all workspace packages and sets the version of each ",
            "package to the given version.",
            "If a package depends on another package from the workspace, the",
            "dependency version is updated as well.",
            "",
        ].join("\n"),
    );
    process.exit(1);
}
const newVersion = process.argv[2];
const rootPackagePath = "package.json";
const lockFilePath = "package-lock.json";

try {
    const lock = readLockfile(lockFilePath);
    const workspaces = readWorkspaces(rootPackagePath);
    const allPackages = workspaces.map((ws) => ws.pkg);
    const bumpPackages = allPackages.filter(
        (pkg) => pkg.version !== undefined && pkg.version !== newVersion,
    );
    /** @type {Log[]} */
    const log = [];
    for (const pkg of bumpPackages) {
        log.push({
            pkg,
            message: `updated version from ${pkg.version} to ${newVersion}`,
        });
        pkg.version = newVersion;
        findLockPackage(lock, pkg.name).version = newVersion;
    }
    for (const pkg of bumpPackages) {
        // update deps in workspace package.json
        for (const other of allPackages) {
            log.push(...updatePackageDep(other, pkg.name, newVersion));
        }
        // update deps in package-lock.json
        for (const lockPkg of Object.values(lock.packages)) {
            updatePackageDep(lockPkg, pkg.name, newVersion);
        }
    }
    if (log.length > 0) {
        for (const { path, pkg } of workspaces) {
            writeJson(path, pkg);
        }
        writeJson(lockFilePath, lock);
        process.stdout.write(formatLog(log) + "\n");
    }
} catch (e) {
    process.stderr.write(String(e) + "\n");
    process.exit(1);
}

/**
 * @typedef {{path: string; pkg: Package}} Workspace
 */

/**
 * Read the given root package.json file, and return an array of workspace
 * packages.
 *
 * @param {string} rootPackagePath
 * @return {Workspace[]}
 */
function readWorkspaces(rootPackagePath) {
    const root = readRootPackage(rootPackagePath);
    const rootDir = dirname(rootPackagePath);
    return root.workspaces
        .flatMap((ws) => globSync(join(rootDir, ws, "package.json")))
        .filter((path) => existsSync(path))
        .map((path) => {
            return { path, pkg: readPackage(path) };
        });
}

/**
 * @typedef {{message: string, pkg: Package}} Log
 */

/**
 * @param {Package|LockPackage} pkg
 * @param {string} depName
 * @param {string} toVersion
 * @return {Log[]}
 */
function updatePackageDep(pkg, depName, toVersion) {
    if (toVersion === undefined) {
        throw new Error("toVersion undefined");
    }
    /** @type {Log[]} */
    const log = [];
    for (const key of [
        "dependencies",
        "devDependencies",
        "peerDependencies",
        "optionalDependencies",
    ]) {
        if (!Object.hasOwn(pkg, key)) {
            continue;
        }
        /** @type { Record<string, string> } */
        const deps = pkg[key];
        const from = deps[depName];
        if (from === undefined) {
            continue;
        }
        let to;
        if (from.startsWith("^")) {
            to = `^${toVersion}`;
        } else if (from.startsWith("~")) {
            to = `~${toVersion}`;
        } else if (from.startsWith("=")) {
            to = `=${toVersion}`;
        } else if (from === "*") {
            to = `*`;
        } else {
            to = toVersion;
        }
        if (from === to) {
            continue;
        }
        deps[depName] = to;
        log.push({
            pkg,
            message: `updated ${key}["${depName}"] from ${from} to ${to}`,
        });
    }
    return log;
}

/**
 *
 * @param {Log[]} log
 * @return {string}
 */
function formatLog(log) {
    const lines = [];
    const updatesByName = {};
    for (const l of log) {
        if (updatesByName[l.pkg.name] === undefined) {
            updatesByName[l.pkg.name] = [];
        }
        updatesByName[l.pkg.name].push(l);
    }
    for (const name of Object.keys(updatesByName).sort()) {
        lines.push(`${name}:`);
        for (const update of updatesByName[name]) {
            lines.push(`  ${update.message}`);
        }
    }
    return lines.join("\n");
}

/**
 * @typedef {{name: string; version?: string; private?: boolean}} Package
 */

/**
 * @param {string} path
 * @return {Package}
 */
function readPackage(path) {
    const json = JSON.parse(readFileSync(path, "utf-8"));
    if (typeof json !== "object" || json === null) {
        throw new Error(`Failed to parse ${path}`);
    }
    const lock = JSON.parse(readFileSync(path, "utf-8"));
    if (typeof lock !== "object" || lock === null) {
        throw new Error(`Failed to parse ${path}`);
    }
    if (!("name" in json) || typeof json.name != "string") {
        throw new Error(`Missing "name" in ${path}`);
    }
    if ("version" in json) {
        if (typeof json.version != "string") {
            throw new Error(`Invalid "version" in ${path}`);
        }
    } else if (!("private" in json) || json.private !== true) {
        throw new Error(`Need either "version" or "private":true in ${path}`);
    }
    return lock;
}

/**
 * @typedef {{packages: Record<string, LockPackage>}} Lockfile
 */

/**
 * @typedef {{name?: string; version?: string}} LockPackage
 */

/**
 * @param {string} path
 * @return {Lockfile}
 */
function readLockfile(path) {
    const lock = JSON.parse(readFileSync(path, "utf-8"));
    if (typeof lock !== "object" || lock === null) {
        throw new Error(`Failed to parse ${path}`);
    }
    if (!("lockfileVersion" in lock) || (lock.lockfileVersion !== 2 && lock.lockfileVersion !== 3)) {
        throw new Error(`Unsupported lock file version in ${path}`);
    }
    if (typeof lock.packages != "object" || lock.packages == null) {
        throw new Error(`Missing "packages" in ${path}`);
    }
    return lock;
}

/**
 * Locates an entry for a local workspace package in a lock file.
 * Throws an error if not found.
 *
 * @param {Lockfile} lock
 * @param {string} packageName
 * @return {LockPackage}
 */
function findLockPackage(lock, packageName) {
    for (const [path, lockPkg] of Object.entries(lock.packages)) {
        if (Object.hasOwn(lockPkg, "name") && lockPkg.name === packageName) {
            return lockPkg;
        }
        // In some situations, the entry for a local package doesn't have a "name" property.
        // We check the path of the entry instead: If the last path element is the same as
        // the package name without scope, it's the entry we are looking for.
        if (path.startsWith("node_modules/")) {
            // Not a local workspace package
            continue;
        }
        const lastPathEle = path.split("/").pop();
        const packageShortname = packageName.split("/").pop();
        if (lastPathEle === packageShortname) {
            return lockPkg;
        }
    }
    throw new Error(
        `Cannot find package ${packageName} in lock file. Run npm install?`,
    );
}

/**
 * @typedef {{ name?: string; version?: string; workspaces: string[] }} RootPackage
 */

/**
 * @param {string} path
 * @return {RootPackage}
 */
function readRootPackage(path) {
    const json = JSON.parse(readFileSync(path, "utf-8"));
    if (typeof json !== "object" || json === null) {
        throw new Error(`Failed to parse ${path}`);
    }
    if (
        !Array.isArray(json.workspaces) ||
        json.workspaces.some((w) => typeof w !== "string")
    ) {
        throw new Error(`Missing or malformed "workspaces" array in ${path}`);
    }
    return json;
}

/**
 * @param {string} path
 * @param {any} json
 */
function writeJson(path, json) {
    writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
}
